Documents: Use single-transaction create/update-and-publish endpoints (FE)#23031
Documents: Use single-transaction create/update-and-publish endpoints (FE)#23031iOvergaard wants to merge 10 commits into
Conversation
… (FE) Replace the separate save+publish calls on the document save-and-publish path with the new create-and-publish / update-and-publish endpoints, and keep unpublished but edited variants dirty after reload via a current-state re-merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-take-three' into v17/feature/68071-save-and-publish-fe
|
Claude finished @iOvergaard's task in 5m 24s —— View job PR ReviewTarget: Wires the document backoffice to new combined create/update-and-publish endpoints, replacing the previous two-step save-then-publish flow with a single atomic server call; adds logic to re-apply unpublished variant edits after the reload so they remain dirty.
Suggestions
Approved with Suggestions for improvementGood to go, but please carefully consider the importance of the suggestions. Labels applied: |
There was a problem hiding this comment.
Pull request overview
This PR updates the Documents backoffice save-and-publish flow to use the new single-transaction Management API endpoints (create-and-publish / update-and-publish), and adds client-side state handling to keep unpublished edits “dirty” after a publish-triggered reload.
Changes:
- Add
createAndPublish/updateAndPublishto the document detail server data source (plusculturesToPublishmapping + re-read). - Wire the publishing workspace to call the combined operation (instead of save + publish) and restore unpublished edits after reload.
- Add unit tests covering the save-and-publish data-state behavior and the single combined call path, plus MSW/mock support for the new endpoints.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace.context.ts | Adds combined create/update-and-publish orchestration and post-reload “dirty variant” restoration logic. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/context/document-workspace-save-and-publish.context.test.ts | New unit tests verifying dirty-state behavior after save-and-publish and ensuring only one combined call is used. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.ts | Adds createAndPublish / updateAndPublish methods and culturesToPublish mapping with re-read. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.server.data-source.test.ts | New unit tests for update-and-publish behavior (requested cultures + invariant case). |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/repository/detail/document-detail.repository.ts | Exposes repository pass-throughs for the new server data source methods. |
| src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts | Switches save-and-publish flow to the combined endpoint and reapplies unpublished edits after reload. |
| src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts | Regenerates OpenAPI types to include new request models and routes. |
| src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts | Regenerates OpenAPI SDK methods for the new endpoints. |
| src/Umbraco.Web.UI.Client/mocks/msw-handlers/document/detail.handlers.ts | Adds MSW handlers for create-and-publish and update-and-publish endpoints. |
| src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts | Adds mock DB publishing support for combined create/update-and-publish operations. |
- Deduplicate request-body mapping in the document data source (shared #mapCreateRequestBody / #mapUpdateRequestBody) to remove create vs create-and-publish duplication (CodeScene). - Split performCreateOrUpdateAndPublish into #createAndPublish / #updateAndPublish to reduce method complexity (CodeScene), and clarify its post-condition in JSDoc. - Default parentUnique to null on the repository createAndPublish overload (Claude). - De-duplicate segments before expanding variant lists (Copilot). - Add a createAndPublish data-source unit test (Copilot). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create/update-and-publish data-source methods re-read the full document after the mutation, but the publishing workspace context immediately calls reload() (which re-fetches and refreshes state) and discards that result. Remove the data-source re-read so save-and-publish fetches the document once (the reload) instead of twice. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hi, Is it possible to maintain the current domain split, with all publishing-related methods located in the publishing repository and publishing context? This would mean that methods like createAndPublish would be moved from the DocumentDetailRepository to the UmbDocumentPublishingRepository. I believe this separation of responsibilities aligns better with how package authors may want to extend a workspace with additional functionality, giving us as much flexibility as possible for workspace extensions. Additionally, it will be beneficial if other entities require publishing functionality, as we would only need to "borrow" the Publishing domain across. |
|
Reviewing purely from a testing perspective, I can see the endpoints being called as expected for "create and publish" and "update and publish". There's an issue with "create and publish" in that after the operation completes there's an immediate and unnecessary display of the "Discard unsaved changes" dialog. If I click "Stay" and then navigate away, there's no secondary display of this dialog, which is correct, so seems there's an issue where this is incorrectly triggered immediately after the create and publish, presumably as it navigates to the "edit" view for the newly created document. |
|
@madsrasmussen Great question! Agreed on keeping the domain split. One heads-up: moving the The catch is the |
|
@AndyButland Interesting finding. I didn't see that myself before, but I will test it out. I think it may be caused by the fact that we now don't overwrite the whole dataset after create/update, because you may not have save-published all variants. Did you have any variants on your page, or any of the known problematic property editors, such as Tiptap with blocks on the document? |
|
I checked - I see this on invariant documents, but it does seem to be property editor related, I've tested with an invariant document that does have an RTE on it, but I haven't populated it with anything. |
|
@iOvergaard I don’t fully remember the entire ceremony around create/update, but it is probably quite complicated. I think there is something interesting in exposing “just enough” so that additional workspace contexts will be able to “prepare” data for a create/save. So maybe we need to introduce new public methods to support this 🤔 I could imagine that other actions might be interested in doing a save/create before their own action and might introduce combined endpoints too. |
After create-and-publish the workspace flipped isNew=false before its data state was reconciled (that happens in the subsequent reload + transfer). The flip schedules the new->edit redirect, whose navigation guard then saw a transient dirty state and popped a "Discard unsaved changes" dialog — most visible on an invariant document with an empty RTE (reported by Andy Butland). Reconcile persisted to the just-saved data before flipping isNew. Using saveData (not the full current data) keeps published variants clean while edited-but-unpublished variants stay dirty, so their edits remain preserved and navigation-guarded during the brief pre-reload window. Update-and-publish is unaffected (no redirect). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Following review feedback (Mads Rasmussen), keep all publishing-related HTTP in the publishing repository/data source rather than the document detail layer: - Move createAndPublish/updateAndPublish from UmbDocumentServerDataSource + UmbDocumentDetailRepository to UmbDocumentPublishingServerDataSource + UmbDocumentPublishingRepository. Extract the shared create/update request-body mapping to document-detail-request.mappers.ts so it is not duplicated. - The publishing workspace context orchestrates the combined call and asks the document workspace context to apply the create/update lifecycle via new public finalizeCreate/finalizeUpdate methods. This keeps setIsNew and _workspaceEventUnique private — no shared workspace base-class changes. - finalizeCreate carries the create-and-publish fix (reconcile persisted to the saved data before the new->edit redirect) so an edited-but-unpublished variant stays dirty while published variants are clean. Move the and-publish data-source test to the publishing folder; rework the save-and-publish/create-and-publish context tests to drive the publishing data source + finalize methods. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#createOrUpdateAndPublish re-checked #documentWorkspaceContext although its only caller (#performSaveAndPublish) already guards it; pass the narrowed reference in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I was still seeing the same issue @iOvergaard after pulling down the latest - but for one particular document type and not others if I tried to recreate. I found evidence of the problem though:
You see the elements in the I'll give you the diff that looks to fix this in - * Persisted is reconciled to `savedData` (not the full current data) BEFORE flipping isNew: that flip
- * triggers the new->edit redirect, whose navigation guard would otherwise see a transient dirty state
- * (the caller's reload reconciles only afterwards) and pop a spurious "Discard unsaved changes" dialog
- * (reported by Andy Butland). Using `savedData` keeps published variants clean while edited-but-
- * unpublished variants correctly stay dirty, so their edits remain guarded during the pre-reload window.
+ * Both persisted and current are reconciled to `savedData` BEFORE flipping isNew, so the new->edit
+ * redirect that flip triggers can never see a dirty state and pop a spurious "Discard unsaved changes"
+ * dialog. Edited-but-unpublished variants are temporarily absent from the current state until the
+ * caller's reload + transferPublishedVariantsToCurrent restores them (dirty again).
* @param {UmbDocumentDetailModel} savedData - The data that was sent to the server (constructSaveData)
* @returns {Promise<void>}
* @memberof UmbDocumentWorkspaceContext
*/
public async finalizeCreate(savedData: UmbDocumentDetailModel): Promise<void> {
const parent = this._internal_getCreateUnderParent();
if (!parent) throw new Error('Parent is not set');
+ // Set persisted AND current to the same data BEFORE flipping isNew: that flip triggers the
+ // new->edit redirect, whose navigation guard compares the two states with an order-sensitive
+ // JSON.stringify. `savedData` is a merge-processed projection of the draft (its values array can
+ // be ordered differently, and resolver-backed editors may rewrite values), so reconciling only
+ // persisted still leaves a spurious mismatch -> "Discard unsaved changes" dialog.
+ // Edited-but-unpublished variants are restored (and become dirty again) by the
+ // caller's reload + transferPublishedVariantsToCurrent.
this._data.setPersisted(savedData);
+ this._data.setCurrent(savedData);
this.setIsNew(false);The test
|
Andy Butland found the create-and-publish "Discard unsaved changes" dialog still appeared for some document types. The dirty guard's jsonStringComparison is order-sensitive, and savedData (a merge-processed projection of the draft) can order its values array differently from current — so reconciling only persisted still left a spurious mismatch. Set both persisted and current to savedData before flipping isNew, so the new->edit redirect can never observe a dirty state. Edited-but-unpublished variants are restored (dirty again) by the caller's reload + transferPublishedVariantsToCurrent. Update the create-and-publish "no data loss" test to assert the end state (after reload + transfer) instead of the intermediate redirect window, since the unpublished variant's edit is briefly absent during it by design. Diagnosis and fix proposed by Andy Butland. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks Andy, that's a good find — the Confirmed it's what you said: the dirty check is an order-sensitive
You were right about the test as well — it was asserting the intermediate state. Moved it to check the end state after reload + transfer, and renamed it. Green here. Give it another go on the doc type that was reproducing for you when you get a chance? Want to confirm it's gone on your side too. |
|
Yes, this is working as expected now @iOvergaard. I'll mark as approved from a testing perspective - but if you are still in discussions with @madsrasmussen and @nielslyngsoe on the code/architecture side of things, obviously please wait for merging until you have resolved everything there. |
|
Thanks, @AndyButland. @madsrasmussen, I implemented your suggestions. Would you check it, please, and let me know what you think? |
| * @returns {Promise<void>} | ||
| * @memberof UmbDocumentWorkspaceContext | ||
| */ | ||
| public async finalizeUpdate(): Promise<void> { |
There was a problem hiding this comment.
Seems inconsistent that finalizeUpdate takes no arguments.
But finalizeCreate retrieves the updated data and sets that.
That is a bit of an annoying inconsistency, as they smell like two identical methods for the various needs, create/update.
There was a problem hiding this comment.
Fair point — finalizeUpdate now takes the saved data and reconciles persisted/current just like finalizeCreate (both mirroring their base-class counterparts), fixed in ad059a0.
…d docs Review feedback from Niels: finalizeUpdate now takes the saved data and reconciles persisted/current like finalizeCreate (mirroring the base _update), the finalize*/transfer JSDocs describe only the method's own responsibility, and the transfer parameter is renamed to currentData to match the method name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eature/68071-save-and-publish-fe
| // Clear stale published data and pending changes state so the | ||
| // persistedData observer does not run a comparison against outdated | ||
| // data during reload, which would briefly show a false-positive | ||
| // "pending changes" state. | ||
| this.#clear(); | ||
|
|
||
| // reload the document so all states are updated after the publish operation | ||
| await this.#documentWorkspaceContext.reload(); | ||
| await this.#loadAndProcessLastPublished(); | ||
| // reload the document so all states are updated after the publish operation | ||
| await this.#documentWorkspaceContext.reload(); | ||
|
|
||
| // The reload resets the current data state to the full server document. Re-apply the edits of the | ||
| // variants that were not published, so they remain dirty (and the Discard-Changes guard fires). | ||
| await this.#documentWorkspaceContext.transferPublishedVariantsToCurrent(dirtyData, variantIds); |
There was a problem hiding this comment.
There is something about this part that we need to look into.
I did a little bit of investigation on it, and from how things works with Discard Changes, then this seems like the only option. But to me there is a lot of arrows pointing towards how the Dicard Changes mechanisme works.
The reason why it itches a bit, from an architectural standpoint, is that for un-saved variants we shortly change it to be like it is on the server and then revert back to the client-draft. To avoid the Discard Changes, I would rather like the discard changes to understand that switching between Create/Edit should not trigger the check for unsaved changes logic.
I would like to dive further into this at one point, but this is currently my only comment on this PR, so putting it here to leave that thought available for who comes around.
There was a problem hiding this comment.
Yeah, you're right — and that reconcile-then-revert is the bit that bugs me too, not just you. Presenting an unsaved variant as the server state for a split second and then flipping it back is the kind of thing that works but never sits quite right. Agree the real fix is teaching the guard that Create→Edit isn't a navigate-away, rather than dancing around it out here. Didn't want to drag it into this PR though since it's shared infra (media and the rest go through the same redirect), so I've split it out: #23086. Thanks for digging into it 🙂

Description
Frontend half of the single-transaction save-and-publish work (ADO 68071, under PBI 68081). The backend endpoints come from #22812, and this targets that feature branch so the whole thing lands on
v17/devtogether.Save-and-publish used to be two server calls —
PUT /document/{id}(save) thenPUT /document/{id}/publish— plus a reload. This wires the backoffice to the new combined endpoints so it's a single transaction.Where it lives
All the publish-related HTTP sits in the publishing domain rather than the detail layer (kept there on review feedback, so other entities/actions can reuse it if they grow combined endpoints of their own):
UmbDocumentPublishingServerDataSource/UmbDocumentPublishingRepository— newcreateAndPublish/updateAndPublish, plus a#mapCulturesToPublishhelper that emits only real culture codes (invariant content types send an empty array, matching the server contract).umbMapDocumentCreateRequestBody/umbMapDocumentUpdateRequestBody) build the bodies and are used by both the plaincreate/updateand the and-publish calls, so there's no duplication.UmbDocumentWorkspaceContext— publicfinalizeCreate/finalizeUpdateapply the same create/update workspace lifecycle the base save flow would (mark no-longer-new, tree events, reconcile the data state) after the publishing context has made the call. This keepssetIsNewand the workspace event discriminator private, so there are no shared base-class changes. PlustransferPublishedVariantsToCurrent(below).UmbDocumentPublishingWorkspaceContext.#performSaveAndPublishorchestrates the flow: one combined call → reload → transfer.Keeping unpublished edits dirty (ADO 68071)
After save-and-publish, the reload resets both persisted and current state to the full server document.
transferPublishedVariantsToCurrentre-applies the still-unpublished, edited variants from the pre-publish draft onto the current state (viaUmbMergeContentVariantDataController), so a variant you changed but did not publish stays dirty and the Discard-Changes guard still fires. Verified: edit English + Danish, publish English only → Danish stays dirty with its edit intact.No spurious "Discard unsaved changes" dialog on create
Creating and publishing a brand-new document flips
isNew, which triggers the new→edit redirect, and that redirect's navigation guard was catching the workspace mid-transition and popping a Discard dialog.finalizeCreatenow reconciles both persisted and current to the saved data before the flip, so the guard sees a clean workspace whatever the doc type — the dirty check is an order-sensitive JSON comparison, and the saved data can order itsvaluesdifferently from the draft, so reconciling only one side wasn't enough. The unpublished variants are restored (dirty again) by the reload + transfer that follow, so nothing is lost. Thanks to @AndyButland for spotting this and pinning down the ordering cause.Not changed
publish()methods are still used by scheduling, the publish entity action, and the publish bulk action.Testing
culturesToPublishcorrectly; the 68071 data-state behaviour (published variant clean, unpublished edit stays dirty — fails on the pre-fix code, passes after); a single combined call (no separate publish); and the create-and-publish path staying clean through the redirect window then restoring the unpublished edit after reload + transfer.OpenApi.json.Loading / fetch behaviour
Progress during save & publish shows only on the action button (the in-progress
setExecutingstate from #22551). The full-workspace loader is intentionally not raised — what's on screen is what was just published, so there's no reason to blank and reload the editor.reload()refreshes the workspace state in place without toggling theloadingflag.Post-publish requests are one
GET /document/{id}(reload),GET /document/{id}/published(recomputes the published-vs-draft "pending changes" indicator — pre-existing behaviour, its result is used), and a backgroundGET /tree/document/{id}/siblings. The redundant secondGET /document/{id}the data source used to issue after mutating has been removed, so the document is fetched once post-publish.Related to #21152
🤖 Generated with Claude Code